查看原文
其他

使用PaddleFluid和TensorFlow训练RNN语言模型

让你更懂AI PaperWeekly 2019-03-29


专栏介绍:Paddle Fluid 是用来让用户像 PyTorch 和 Tensorflow Eager Execution 一样执行程序。在这些系统中,不再有模型这个概念,应用也不再包含一个用于描述 Operator 图或者一系列层的符号描述,而是像通用程序那样描述训练或者预测的过程。


本专栏将推出一系列技术文章,从框架的概念、使用上对比分析 TensorFlow 和 Paddle Fluid,为对 PaddlePaddle 感兴趣的同学提供一些指导。

在图像领域,最流行的 building block 大多以卷积网络为主。上一篇我们介绍了如何在 PaddleFluid 和 TensorFlow 上训练图像分类任务。卷积网络本质上依然是一个前馈网络,在神经网络基本单元中循环神经网络是建模序列问题最有力的工具, 有着非常重要的价值。自然语言天生是一个序列,在自然语言处理领域(Nature Language Processing,NLP)中,许多经典模型都基于循环神经网络单元。可以说自然语言处理领域是 RNN 的天下。


这一篇以 NLP 领域的 RNN 语言模型(RNN Language Model,RNN LM)为实验任务,对比如何使用 PaddleFluid 和 TensorFlow 两个平台实现序列模型。 这一篇中我们会看到 PaddleFluid 和 TensorFlow 在处理序列输入时有着较大的差异:PaddleFluid 默认支持非填充的 RNN 单元,在如何组织 mini-batch 数据提供序列输入上也简化很多。


如何使用代码


本篇文章配套有完整可运行的代码, 请从随时从 github [1] 上获取最新代码。代码包括以下几个文件:



注意:在运行模型训练之前,请首先进入 data 文件夹,在终端运行 sh download.sh 下载训练数据。 


在终端运行以下命令便可以使用默认结构和默认参数运行 PaddleFluid 训练 RNN LM。


python rnnlm_fluid.py


在终端运行以下命令便可以使用默认结构和默认参数运行 TensorFlow 训练 RNN LM。


python rnnlm_tensorflow.py


背景介绍


one-hot和词向量表示法 


计算机如何表示语言是处理 NLP 任务的首要问题。这里介绍将会使用到的 one-hot 和词向量表示法。 


one-hot 表示方法:一个编码单元表示一个个体,也就是一个词。于是,一个词被表示成一个长度为字典大小的实数向量,每个维度对应字典里的一个词,除了该词对应维度上的值是 1,其余维度都是 0。 


词向量表示法:与 one-hot 表示相对的是 distributed representation ,也就是常说的词向量:用一个更低维度的实向量表示词语,向量的每个维度在实数域 RR 取值。 


在自然语言处理任务中,一套好的词向量能够提供丰富的领域知识,可以通过预训练获取,或者与最终任务端到端学习而来。 


循环神经网络 


循环神经网络(Recurrent Neural Network)是一种对序列数据建模的重要单元,模拟了离散时间(这里我们只考虑离散时间)动态系统的状态演化。“循环” 两字刻画了模型的核心:上一时刻的输出作为下一个时刻的输入,始终留在系统中如下面的图 1 所示,这种循环反馈能够形成复杂的历史。自然语言是一个天生的序列输入,RNN 恰好有能力去刻画词汇与词汇之间的前后关联关系,因此,在自然语言处理任务中占有重要的地位。


▲ 图1. 最简单的RNN单元


RNN 形成“循环反馈” 的过程是一个函数不断复合的过程,可以等价为一个层数等于输入序列长度的前馈神经网络,如果输入序列有 100 个时间步,相当于一个 100 层的前馈网络,梯度消失和梯度爆炸的问题对 RNN 尤为严峻。


直觉上大于 1 的数连乘越乘越大,极端时会引起梯度爆炸;小于 1 的数连乘越乘越小,极端时会引起梯度消失。梯度消失也会令在循环神经网络中,后面时间步的信息总是会”压过”前面时间步。如果 t 时刻隐层状态依赖于 t 之前所有时刻,梯度需要通过所有的中间隐层逐时间步回传,这会形成如图 2 所示的一个很深的求导链。


▲ 图2. t时刻依赖t时刻之前所有时刻


在许多实际问题中时间步之间相互依赖的链条并没有那么长,t 时刻也许仅仅依赖于它之前有限的若干时刻。很自然会联想到:如果模型能够自适应地学习出一些如图 3 所示的信息传播捷径来缩短梯度的传播路径,是不是可以一定程度减梯度消失和梯度爆炸呢?答案是肯定的,这也就是 LSTM 和 GRU 这类带有 “门控”思想的神经网络单元。


▲ 图3. 自适应地形成一些信息传播的“捷径”


关于 LSTM 更详细的介绍请参考文献 [2],这里不再赘述,只需了解 LSTM/GUR 这些门控循环神经网络单元提出的动机即可。


RNN LM 


语言模型是 NLP 领域的基础任务之一。语言模型是计算一个序列的概率,判断一个序列是否属于一个语言的模型,描述了这样一个条件概率,其中是输入序列中的 T 个词语,用 one-hot 表示法表示。


言模型顾名思义是建模一种语言的模型,这一过程如图 4 所示:


▲ 图4. RNN语言模型


RNN LM的工作流程如下: 


1. 给定一段 one-hot 表示的输入序列 {x1,x2,...,xT},将它们嵌入到实向量空间,得到词向量表示 :{ω1,ω2,...,ωt}。 


2. 以词向量序列为输入,使用 RNN 模型(可以选择LSTM或者GRU),计算输入序列到 t 时刻的编码 ht。 


3. softmax 层以 ht 为输入,预测下一个最可能的词的概率。 


4. ,根据计算误差信号。


PTB数据集介绍


至此,介绍完 RNN LM 模型的原理和基本结构,下面准备开始分别使用 PaddleFluid 和 TensorFlow 来构建我们的 训练任务。这里首先介绍这一篇我们使用 Mikolov 与处理过的 PTB 数据,这是语言模型任务中使用最为广泛的公开数据之一。 PTB 数据集包含 10000 个不同的词语(包含句子结束符 <eos> ,以及表示 低频词的特殊符号 <unk> )。 


通过运行 data 目录下的 download.sh 下载数据,我们将使用其中的 ptb.train.txt 文件进行训练,文件中一行是一句话,文本中的低频词已经全部被替换为 <unk> 预处理时我们会在 每一行的末尾附加上句子结束符 <e>


程序结构


这一节我们首先整体总结一下使用 PaddleFluid 平台和 TensorFlow 运行自己的神经网络模型都有哪些事情需要完成。


PaddleFluid 


1. 调用 PaddleFluid API 描述神经网络模型。PaddleFluid 中一个神经网络训练任务被称之为一段 Fluid Program 。 


2. 定义 Fluid Program 执行设备: place 。常见的有 fluid.CUDAPlace(0) fluid.CPUPlace() 


place = fluid.CUDAPlace(0if conf.use_gpu else fluid.CPUPlace()


注:PaddleFluid 支持混合设备运行,一些运算(operator)没有特定设备实现,或者为了提高全局资源利用率,可以为他们指定不同的计算设备。 


3. 创建 PaddleFluid 执行器(Executor),需要为执行器指定运行设备。


exe = fluid.Executor(place)


让执行器执行 fluid.default_startup_program() ,初始化神经网络中的可学习参数,完成必要的初始化工作。 


5. 定义 DataFeeder,编写 data reader,只需要关注如何返回一条训练/测试数据。 


6. 进入训练的双层循环(外层在 epoch 上循环,内层在 mini-batch 上循环),直到训练结束。


TensorFlow 


1. 调用 TensorFlow API 描述神经网络模型。 TensorFlow 中一个神经网络模型是一个 Computation Graph。 


2. 创建TensorFlow Session用来执行计算图。


sess = tf.Session()


3. 调用 sess.run(tf.global_variables_initializer()) 初始化神经网络中的可学习参数。


4. 编写返回每个 mini-batch 数据的数据读取脚本。


5. 进入训练的双层循环(外层在 epoch 上循环,内层在 mini-batch 上循环),直到训练结束。


如果不显示地指定使用何种设备进行训练,TensorFlow 会对机器硬件进行检测(是否有 GPU), 选择能够尽可能利用机器硬件资源的方式运行。


从以上的总结中可以看到,PaddleFluid 程序和 TensorFlow 程序的整体结构非常相似,使用经验可以非常容易的迁移。


构建网络结构及运行训练


加载训练数据


PaddleFluid 


定义 输入data layers


PaddleFluid 模型通过 fluid.layers.data 来接收输入数据。图像分类网络以图片以及图片对应的类别标签作为网络的输入:


word = fluid.layers.data(
        name="current_word", shape=[1], dtype="int64", lod_level=1)
lbl = fluid.layers.data(
        name="next_word", shape=[1], dtype="int64", lod_level=1)


1. 定义 data layer 的核心是指定输入 Tensor 的形状( shape )和类型。 


2. RNN LM 使用 one-hot 作为输入,一个词用一个和字典大小相同的向量表示,每一个位置对应了字典中的 一个词语。one-hot 向量仅有一个维度为 1, 其余全部为 0。因此为了节约存储空间,通常都直接用一个整型数表示给出词语在字典中的 id,而不是真的创建一个和词典同样大小的向量 ,因此在上面定义的 data layer 中 word lbl 的形状都是 1,类型是 int64 。 


3. 需要特别说明的是,实际上 word  lbl 是两个 [batch_size x 1] 的向量,这里的 batch size 是指一个 mini-batch 中序列中的总词数。对序列学习任务, mini-batch 中每个序列长度 总是在发生变化,因此实际的 batch_size 只有在运行时才可以确定。 batch size 总是一个输入 Tensor 的第 0 维,在 PaddleFluid 中指定 data layer 的 shape 时,不需要指定 batch size 的大小,也不需要考虑占位。框架会自动补充占位符,并且在运行时 设置正确的维度信息。因此,上面的两个 data layer 的 shape 都只需要设置第二个维度,也就是 1。


LoD Tensor和Non-Padding的序列输入 


与前两篇文章中的任务相比,在上面的代码片段中定义 data layer 时,出现了一个新的 lod_level 字段,并设置为 1。这里就要介绍在 Fluid 系统中表示序列输入的一个重要概念 LoDTensor。 


那么,什么是 LoD(Level-of-Detail) Tensor 呢? 


1. Tensor 是 nn-dimensional arry 的推广,LoDTensor 是在 Tensor 基础上附加了序列信息。 


2. Fluid 中输入、输出,网络中的可学习参数全部统一使用 LoDTensor(n-dimension array)表示,对非序列数据,LoD 信息为空。一个 mini-batch 输入数据是一个 LoDTensor。 


3. 在 Fluid 中,RNN 处理变长序列无需 padding,得益于 LoDTensor表示。 


4. 可以简单将 LoD 理解为:std::vector<std::vector>


下图是 LoDTensor 示意图(图片来自 Paddle 官方文档):


▲ 图5. LoD Tensor示意图


LoD 信息是附着在一个 Tensor 的第 0 维(也就是 batch size 对应的维度),来对一个 batch 中的数据进一步进行划分,表示了一个序列在整个 batch 中的起始位置。 


LoD 信息可以嵌套,形成嵌套序列。例如,NLP 领域中的段落是一种天然的嵌套序列,段落是句子的序列,句子是词语的序列。 


LoD 中的 level 就表示了序列信息的嵌套:


  • 图 (a) 的 LoD 信息 [0, 5, 8, 10, 14] :这个 batch 中共含有 4 条序列。

  • 图 (b) 的 LoD 信息 [[0, 5, 8, 10, 14] /*level=1*/, [0, 2, 3, 5, 7, 8, 10, 13, 14] /*level=2*/] :这个 batch 中含有嵌套的双层序列。


有了 LoDTensor 这样的数据表示方式,用户不需要对输入序列进行填充,框架会自动完成 RNN 的并行计算处理。


如何构造序列输入信息 


明白了 LoD Tensor 的概念之后,另一个重要的问题是应该如何构造序列输入。在 PaddleFluid 中,通过 DataFeeder 模块来为网络中的 data layer 提供数据,调用方式如下面的代码所示:


train_reader = paddle.batch(
        paddle.reader.shuffle(train_data, buf_size=51200),
        batch_size=conf.batch_size)

place = fluid.CUDAPlace(0if conf.use_gpu else fluid.CPUPlace()
feeder = fluid.DataFeeder(feed_list=[word, lbl], place=place)


观察以上代码,需要用户完成的仅有:编写一个实现读取一条数据的 python 函数: train_data train_data 的代码非常简单,我们再来看一下它的具体实现 [3]


def train_data(data_dir="data"):
    data_path = os.path.join(data_dir, "ptb.train.txt")
    _, word_to_id = build_vocab(data_path)

    with open(data_path, "r"as ftrain:
        for line in ftrain:
            words = line.strip().split()
            word_ids = [word_to_id[w] for w in words]
            yield word_ids[0:-1], word_ids[1:]


在上面的代码中: 


1. train_data 是一个 python generator ,函数名字可以任意指定,无需固定。 


2. train_data 打开原始数据数据文件,读取一行(一行既是一条数据),返回一个 python list,这个 python list 既是序列中所有时间步。具体的数据组织方式如下表所示(其中,f 代表一个浮点数,i 代表一个整数):



3. paddle.batch() 接口用来构造 mini-batch 输入,会调用 train_data 将数据读入一个 pool 中,对 pool 中的数据进行 shuffle,然后依次返回每个 mini-batch 的数据。


TensorFlow 


TensorFlow 中使用占位符 placeholder 接收 训练数据,可以认为其概念等价于 PaddleFluid 中的 data layer。同样的,我们定义了如下两个 placeholder 用于接收当前词与下一个词语:


def placeholders(self):
    self._inputs = tf.placeholder(tf.int32,
                                  [None, self.max_sequence_length])
    self._targets = tf.placeholder(tf.int32, [None, self.vocab_size])


1. placeholder 只存储一个 mini-batch 的输入数据。与 PaddleFluid 中相同, _inputs 这里接收的是 one-hot 输入,也就是该词语在词典中的 index,one-hot 表示 会进一步通过此词向量层的作用转化为实值的词向量表示。


2. 需要注意的是,TensorFlow 模型中网络输入数据需要进行填充,保证一个 mini-batch 中序列长度 相等。也就是一个 mini-batch 中的数据长度都是 max_seq_length ,这一点与 PaddleFluid 非常不同。 


通常做法 是对不等长序列进行填充,在这一篇示例中我们使用一种简化的做法,每条训练样本都按照 max_sequence_length 来切割,保证一个 mini-batch 中的序列是等长的。 


于是, _input shape=[batch_size, max_sequence_length]  max_sequence_length 即为 RNN 可以展开长度。


构建网络结构 


PaddleFluid RNN LM 


这里主要关注最核心的 LSTM 单元如何定义:


def __rnn(self, input):
    for i in range(self.num_layers):
        hidden = fluid.layers.fc(
            size=self.hidden_dim * 4,
            bias_attr=fluid.ParamAttr(
                initializer=NormalInitializer(loc=0.0, scale=1.0)),
            input=hidden if i else input)
        lstm = fluid.layers.dynamic_lstm(
            input=hidden,
            size=self.hidden_dim * 4,
            candidate_activation="tanh",
            gate_activation="sigmoid",
            cell_activation="sigmoid",
            bias_attr=fluid.ParamAttr(
                initializer=NormalInitializer(loc=0.0, scale=1.0)),
            is_reverse=False)
    return lstm


PaddleFluid 中的所有 RNN 单元(RNN/LSTM/GRU)都支持非填充序列作为输入,框架会自动完成不等长序列的并行处理。当需要堆叠多个 LSTM 作为输入时,只需利用 Python 的 for 循环语句,让一个 LSTM 的输出成为下一个 LSTM 的输入即可。在上面的代码片段中有一点需要特别注意:PaddleFluid 中的 LSTM 单元是由 fluid.layers.fc+ fluid.layers.dynamic_lstm 共同构成的。


▲ 图6. LSTM计算公式


图 6 是 LSTM 计算公式,图中用红色圈起来的计算是每一时刻输入矩阵流入三个门和 memory cell 的之前的映射。PaddleFluid 将这个四个矩阵运算合并为一个大矩阵一次性计算完毕, fluid.layers.dynamic_lstm 不包含这部分运算。因此: 


1. PaddleFluid 中的 LSTM 单元是由 fluid.layers.fc + fluid.layers.dynamic_lstm 。 


2. 假设 LSTM 单元的隐层大小是 128 维, fluid.layers.fc fluid.layers.dynamic_lstm 的 size 都应该设置为 128 * 4,而不是 128。 


TensorFlow RNN LM 


这里主要关注最核心的 LSTM 单元如何定义:


def rnn(self):
    def lstm_cell():
        return tf.contrib.rnn.BasicLSTMCell(
            self.hidden_dim, state_is_tuple=True)

    cells = [lstm_cell() for _ in range(self.num_layers)]
    cell = tf.contrib.rnn.MultiRNNCell(cells, state_is_tuple=True)

    _inputs = self.input_embedding()
    _outputs, _ = tf.nn.dynamic_rnn(
        cell=cell, inputs=_inputs, dtype=tf.float32)

    last = _outputs[:, -1, :]
    logits = tf.layers.dense(inputs=last, units=self.vocab_size)
    prediction = tf.nn.softmax(logits)


 tf.nn.rnn_cell.BasicLSTMCell(n_hidden, state_is_tuple=True) : 是最基本的 LSTM 单元。 n_hidden 表示 LSTM 单元隐层大小。 state_is_tuple=True 表示返回的状态用一个元祖表示。 


 tf.contrib.rnn.MultiRNNCell : 用来 wrap 一组序列调用的 RNN 单元的 wrapper。 


 tf.nn.dynamic_rnn : 通过指定 mini-batch 中序列的长度,可以跳过 padding 部分的计算,减少计算量。这一篇的例子中由于我们对输入数据进行了处理,将它们都按照 max_sequence_length 切割。 


但是, dynamic_rnn 可以让不同 mini-batch 的 batch size 长度不同,但同一次迭代一个 batch 内部的所有数据长度仍然是固定的。


运行训练 


运行训练任务对两个平台都是常规流程,可以参考上文在程序结构一节介绍的流程,以及代码部分:PaddleFluid vs. TensorFlow,这里不再赘述。

总结


这一篇我们第一次接触 PaddleFluid 和 TensorFlow 平台的序列模型。了解 PaddleFluid 和 TensorFlow 在接受序列输入,序列处理策略上的不同。序列模型是神经网络模型中较为复杂的一类模型结构,可以衍生出非常复杂的模型结构。 


不论是 PaddleFluid 以及 TensorFlow 都实现了多种不同的序列建模单元,如何选择使用这些不同的序列建模单元有很大的学问。到目前为止平台使用的一些其它重要主题:例如多线程多卡,如何利用混合设备计算等我们还尚未涉及。接下来的篇章将会继续深入 PaddleFluid 和 TensorFlow 平台的序列模型处理机制,以及更多重要功能如何在两个平台之间实现。


参考文献


[1]. 本文配套代码

https://github.com/JohnRabbbit/TF2Fluid/tree/master/03_rnnlm

[2]. Understanding LSTM Networks

http://colah.github.io/posts/2015-08-Understanding-LSTMs/

[3]. train_data具体实现

https://github.com/JohnRabbbit/TF2Fluid/blob/master/03_rnnlm/load_data_fluid.py



关于PaperWeekly


PaperWeekly 是一个推荐、解读、讨论、报道人工智能前沿论文成果的学术平台。如果你研究或从事 AI 领域,欢迎在公众号后台点击「交流群」,小助手将把你带入 PaperWeekly 的交流群里。


▽ 点击 | 阅读原文 | 加入社区刷论文

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存